内存优化 —— bitmap pool

内存优化 —— bitmap pool

前言

图片是 Android 中占用内存比较大的模块,对于一张使用 ARGB_8888(一个像素低占用32位,即4个字节),480 x 800 大小的 bitmap 来说,内存就要占用 480 x 800 x 4 / 1024 = 600kb 的内存大小,就算使用 RGB_565 也还是需要 300kb 的内存,而且失去了透明度和一些细节。尤其是针对一些图片列表,图片的缓存尤其重要。

通常我们知道那些著名的第三方图片加载框架用 LRU Cache 来缓存图片,防止对一张已经加载过的图片又加载一遍,但是很少有人关心过当图片移出 Cache 之后发生了什么。当加载场景是一个无限滑动的图片列表时,基本不会出现图片的复用,系统开始频繁创建销毁 bitmap 资源对象,造成了比较大的内存抖动。所以拥有一个可以复用的对象池可以极大地优化上述现象,介于 bitmap 的特殊性,这个需要特殊定制优化,以 Glide 为例。

本文基于 Glide 4.3.1 代码

基础知识

Android 官方有一篇关于 Bitmap 内存管理的文章 Managing Bitmap Memory

Android 各个版本中对于 bitmap 内存优化如下:

  • Android 2.2(API 8)及以下,当垃圾回收触发时使导致整个应用暂停,这种同步的回收会影响性能表现。Android 2.3 加入了并发回收策略,如果 bitmap 没有被引用将会很快被回收
  • Android 2.3.3(API 10)及以下,bitmap 的像素数据存在于 native heap 中,而 bitmap 本身则存在于 Dalvik heap 中。由于 native 内存中的像素数据何时被释放无法预测,所以可能会导致应用内存溢出而崩溃。从 Android 3.0(API 11)到 Android 7.1(API 27),像素数据被移到了 Dalvik heap 中,和 bitmap 一样。而 Android 8.0 中,像素数据又再一次被移到了 native heap 中。可能是改进了回收策略,防止像素数据存在 Dalvik heap 中时,使用大量 bitmap 的场景会导致虚拟机的堆内存溢出。

同时,对于优化 bitmap 的内存管理措施,不同版本也有区别

  • Android 2.3.3(API 10)及以下推荐使用 recycle() 来回收 bitmap 的内存
  • Android 3.0(API 11)开始,引进了 BitmapFactory.Options.inBitmap 选项,如果设置了此选项,那么在加载 bitmap 时,会采用 Options 对象的解码方法尝试重新使用现有的 bitmap。这意味着 Bitmap 的内存被重用。这样做不但能提高性能,而且省去了内存分配和解除的过程。但是,如何使用 inBitmap 有一定的限制。特别是,在Android 4.4(API 19)之前,只支持相同大小的 bitmap,而在 4.4 以后,只需要复用的 bitmap 大于等于被解码的 bitmap 即可

所以在开发 bitmap 的复用池时,一定要注意各个版本的区别,值得庆幸的是现在 Android 3.0 版本以下的基本可以被忽略了,所以我们只需要考虑不同版本的 bitmap 复用策略即可

解析

GlideBuilder 配置选项中提供了 setBitmapPool 的选项,可以传入一个自定义实现 BitmapPool 接口的对象。当然,Glide 提供了默认的实现对象

1
2
3
4
5
6
7
8
9
10
if (bitmapPool == null) {
int size = memorySizeCalculator.getBitmapPoolSize();
if (size > 0) {
// 系统默认实现的 bitmap 缓存池,由字面意思可以得知该缓存池也是采用了 LRU 算法管理缓存
bitmapPool = new LruBitmapPool(size);
} else {
// 提供默认的空实现
bitmapPool = new BitmapPoolAdapter();
}
}

LruBitmapPool

LruBitmapPool 的类描述如下:

An BitmapPool implementation that uses an LruPoolStrategy to bucket Bitmaps and then uses an LRU eviction policy to evict Bitmaps from the least recently used bucket in order to keep the pool below a given maximum size limit.
使用 LruPoolStrategy 管理 bitmaps,通过 LRU 策略维持对象池的大小

LruBitmapPool 的主要成员变量如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 默认的图片配置时 ARGB_8888
private static final Bitmap.Config DEFAULT_CONFIG = Bitmap.Config.ARGB_8888;

// 实现对象池管理复用的对象
private final LruPoolStrategy strategy;
// 允许内存复用的图片配置
private final Set<Bitmap.Config> allowedConfigs;
// 初始化的最大容量
private final int initialMaxSize;
// 一个记录器,只用于测试环境追踪 bitmap 对象,没太大意义
private final BitmapTracker tracker;

// 真正的最大容量,因为会存在设置了大小因子的时候,等于 initialMaxSize * sizeMultiplier
private int maxSize;
// 当前对象池的大小
private int currentSize;
// 对象复用命中的次数
private int hits;
// 对象复用未命中的次数
private int misses;
// 对象存入的次数
private int puts;
// 容量超限触发清理的次数
private int evictions;

// 这些对象可以在测试环境通过 dump() 方法打印

LruPoolStrategy 一般通过 Bitmap 的 height,width 以及 Bitmap.Config 来生成缓存键。缓存池的大小控制则是通过 currentSize 和 maxSize 的比较来决定是否需要移除 LruPoolStrategy 中的对象。

在 getDefaultStrategy() 方法中,已经有两个系统默认的实现,而之所以会有两个实现,是因为上文提及 Android 不同版本复用策略不同导致的

1
2
3
4
5
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
strategy = new SizeConfigStrategy();
} else {
strategy = new AttributeStrategy();
}

SizeConfigStrategy 的实现相较于 AttributeStrategy 的实现更为复杂,我们以 SizeConfigStrategy 为例深入解析
SizeConfigStrategy 的结构并不复杂,但是运用到了比较多的数据结构

SizeConfigStrategy

1
2
3
4
5
6
7
// 用来缓存键的对象池,内部为一个 20 长度的队列,key 的运用非常频繁,需要创建对象池管理
// Key 以 size 和 Bitmap.Config 做关键判断
private final KeyPool keyPool = new KeyPool();
// 这个 GroupedLinkedMap 是作者根据使用场景定义的一个类似于 LinkedHashMap 的数据结构
private final GroupedLinkedMap<Key, Bitmap> groupedMap = new GroupedLinkedMap<>();
// 该对象用于获取最接近可以复用的 key,可以复用大于等于目标 bitmap 大小的对象
private final Map<Bitmap.Config, NavigableMap<Integer, Integer>> sortedSizes = new HashMap<>();

关于这些变量可以结合 get 方法去详细解析

SizeConfigStrategy.get 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Bitmap get(int width, int height, Bitmap.Config config) {
// 通过宽高及配置计算出 bitmap 的大小
int size = Util.getBitmapByteSize(width, height, config);
// 通过 sortedSizes 查找最匹配的 key
Key bestKey = findBestKey(size, config);

Bitmap result = groupedMap.get(bestKey);
if (result != null) {
// 因为对象被取走,所以需要从 sortedSizes 中移除相关数据,必须保证顺序,否则 reconfigure 之后会移除错误的
decrementBitmapOfSize(bestKey.size, result);
result.reconfigure(width, height,
result.getConfig() != null ? result.getConfig() : Bitmap.Config.ARGB_8888);
}
return result;
}

追踪到 findBestKey 方法中去具体查看到底是如何匹配到最佳的 key 以及 sortedSizes 是如何工作的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private Key findBestKey(int size, Bitmap.Config config) {
// 获取缓存键
Key result = keyPool.get(size, config);
// 遍历相同的配置,为了兼容 Bitmap.Config = null 相当于 Bitmap.Config.ARGB_8888 的情况
for (Bitmap.Config possibleConfig : getInConfigs(config)) {
// 获取对应配置下存储的对应大小的 bitmap 数量,NavigableMap 继承自 SortedMap,所以数据是 key 有序的
NavigableMap<Integer, Integer> sizesForPossibleConfig = getSizesForConfig(possibleConfig);
// 向上取整获取到大小最接近的
Integer possibleSize = sizesForPossibleConfig.ceilingKey(size);
// 如果超过了目标值的8倍,则认为没有匹配的值
if (possibleSize != null && possibleSize <= size * MAX_SIZE_MULTIPLE) {
if (possibleSize != size
|| (possibleConfig == null ? config != null : !possibleConfig.equals(config))) {
// 回收生成的 key,返回我们查找到的 key
keyPool.offer(result);
result = keyPool.get(possibleSize, possibleConfig);
}
break;
}
}
return result;
}

这就是大致的逻辑,可以获取到大于目标 bitmap 大小的最接近的可复用的 bitmap,而 put 方法就相对比较简单,只需要往 groupedMap 和 sortedSizes 都插入数据即可,由于池大小是由外部控制,这里也无需进行额外操作。

我们回到 GroupedLinkedMap 去看看,其实 LinkedHashMap 中如果开启了 accessOrder 是可以实现类似于 LRU 的功能的,他将会把 put 或者 get 操作过的数据放到队尾,队首存放的是最少最久使用的数据。而 GroupedLinkedMap 则是针对使用场景去做了修改,GroupedLinkedMap 的类描述如下:

Similar to {@link java.util.LinkedHashMap} when access ordered except that it is access ordered on groups of bitmaps rather than individual objects. The idea is to be able to find the LRU bitmap size, rather than the LRU bitmap object. We can then remove bitmaps from the least recently used size of bitmap when we need to reduce our cache size.

For the purposes of the LRU, we count gets for a particular size of bitmap as an access, even if no bitmaps of that size are present. We do not count addition or removal of bitmaps as an access.

在 GroupedLinkedMap 中存放数据的是相同 key 下的一堆 bitmap 而不是单个的 bitmap 对象,具体表现为 LinkedEntry 中存放的是 List 对象,存入相同的 key 并不会覆盖,而且在 GroupedLinkedMap 就算 get 到的对象目标不存在也是会更新对应值的

GroupedLinkedMap.get 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public V get(K key) {
LinkedEntry<K, V> entry = keyToEntry.get(key);
// 即使获取到的为 null,也会生成一个插入到链表中
if (entry == null) {
entry = new LinkedEntry<>(key);
keyToEntry.put(key, entry);
} else {
key.offer();
}
// 移动链表
makeHead(entry);
// 从 entry 中获取对象并移除,新生成的则为空
return entry.removeLast();
}

而 LinkedLinkedMap.get 方法则不同

1
2
3
4
5
6
7
8
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}

如果目标为空,则不会对链表进行操作

GroupedLinkedMap 移除就是常规的链表队尾移除操作,会先移除 Entry 中 list 里的数据,移除完以后才会将 Entry 移除

AttributeStrategy

而 AttributeStrategy 中则不需要那么复杂的逻辑,因为他只需要 width, height, config 完全对应即可,那就简单的多了。具体逻辑可以自己去看

结语

对于频繁创建对象的场景有必要使用对象池去优化,而 bitmap 由于复用的特殊性需要区分版本对待。日常使用中,推荐使用 Glide 这种第三方库去加载图片,因为其内部实现了三级缓存,如果非要自定义的话,在考虑到 LRU 的同时也要考虑到 bitmap 的复用